#!/usr/bin/python

# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.

'''An FTP server for GooglePages service (see: http://pages.google.com/).

Usage:
  googlepagesftpd.py [options]

Options:
  -h, --help               Prints this help text.
  -b, --bind <port>        Port to bind the server to, defaults to 21.
  -a, --address <host>     Server host used in passive transfers.
  -p, --ports <min>-<max>  Passive transfer ports range.
  
After starting the server, connect to it using your google accout
email and password. Keep in mind that these are sent to the server
unencrypted so you probably want to connect to the server locally
only.

Successful connection gives you access to files stored in
GooglePages. Separate sites are represented as directories.
To create more sites, use the Page Creator (http://pages.google.com).

Current GooglePages limits are:
* total storage space: 100MB
* single file size limit: 10MB
* maximum number of files: 500

One ceavet is that uploading from the FTP server to GooglePages
is done in one step after a whole file is received by the FTP
server. Therefore you will experience a delay at the end of
uploading to FTP.

Arkadiusz Wahlig <arkadiusz.wahlig@googlemail.com>
'''

__version__ = '0.5.3'
__date__ = '25.07.2008'


import sys, os, getopt, time
from pyftpdlib import ftpserver
import urllib, urllib2, cookielib
import mimetools, mimetypes
from cStringIO import StringIO


class Callable:
    def __init__(self, anycallable):
        self.__call__ = anycallable

class MultipartPostHandler(urllib2.BaseHandler):
    '''Enables the use of multipart/form-data for posting forms
    
    02/2006 Will Holcomb <wholcomb@gmail.com>
        
    This library is free software; you can redistribute it and/or
    modify it under the terms of the GNU Lesser General Public
    License as published by the Free Software Foundation; either
    version 2.1 of the License, or (at your option) any later version.
     
    This library is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    Lesser General Public License for more details.
    
    04/2008 Arkadiusz Wahlig <yak@nokix.pasjagsm.pl>
    Modified to accept data from memory instad of file from disk.
    '''
    handler_order = urllib2.HTTPHandler.handler_order - 10 # needs to run first

    def http_request(self, request):
        data = request.get_data()
        if data is not None and not isinstance(data, str):
            v_files = []
            v_vars = []
            try:
                 for(key, value) in data.items():
                     if isinstance(value, tuple):
                         v_files.append((key,) + value)
                     else:
                         v_vars.append((key, value))
            except TypeError:
                systype, value, traceback = sys.exc_info()
                raise TypeError, "not a valid non-string sequence or mapping object", traceback

            if len(v_files) == 0:
                data = urllib.urlencode(v_vars, 0)
            else:
                boundary, data = self.multipart_encode(v_vars, v_files)

                contenttype = 'multipart/form-data; boundary=%s' % boundary
                if(request.has_header('Content-Type')
                   and request.get_header('Content-Type').find('multipart/form-data') != 0):
                    print "Replacing %s with %s" % (request.get_header('content-type'), 'multipart/form-data')
                request.add_unredirected_header('Content-Type', contenttype)

            request.add_data(data)
        
        return request

    def multipart_encode(vars, files, boundary = None, buf = None):
        if boundary is None:
            boundary = mimetools.choose_boundary()
        if buf is None:
            buf = StringIO()
        for(key, value) in vars:
            buf.write('--%s\r\n' % boundary)
            buf.write('Content-Disposition: form-data; name="%s"' % key)
            buf.write('\r\n\r\n' + value + '\r\n')
        for(key, filename, data) in files:
            contenttype = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
            buf.write('--%s\r\n' % boundary)
            buf.write('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (key, filename))
            buf.write('Content-Type: %s\r\n' % contenttype)
            # buffer += 'Content-Length: %s\r\n' % len(data)
            buf.write('\r\n' + data + '\r\n')
        buf.write('--' + boundary + '--\r\n\r\n')
        buf = buf.getvalue()
        return boundary, buf
    multipart_encode = Callable(multipart_encode)

    https_request = http_request


class GooglePagesSite:
    '''Represents a single site in GooglePages serive. Provides methods for
    listing, uploading and deleting files.
    '''
    
    # update validity period in seconds
    update_validity = 5.0
    
    def __init__(self, urlopener, site):
        self.opener = urlopener
        self.site = site
        self.url = 'http://%s.googlepages.com/' % site
        
    def _update(self):
        if hasattr(self, 'lastupdatetime'):
            if self.lastupdatetime + self.update_validity > time.time():
                    return
        data = self.opener.open('http://pages.google.com/manager/%s' % self.site).read()
        for ln in data.splitlines():
            ln = ln.strip()
            if ln.startswith('<meta http-equiv="refresh" content="0; url='):
                url = ln[43:ln.index('">', 44)].replace('&amp;', '&').replace('&#39;', '')
                data = self.opener.open(url).read()
                break
        ## (for debugging)
        #f = file('update_%s.html' % self.site, 'wb')
        #f.write(data)
        #f.close()
        mode = None
        self.urls = {}
        self.sites = [self.site]
        self.files = []
        for ln in data.splitlines():
            ln = ln.strip()
            if ln == '[ "name", "url", "size" ],':
                # found the uploaded files listing
                mode = 'files'
            elif mode == 'files':
                if ln == ');':
                    mode = None
                elif ln in ('[', ']'):
                    pass
                elif ln == ',':
                    self.files.append([])
                else:
                    if ln.endswith(','):
                        ln = ln[:-1]
                    if ln.startswith('"'):
                        ln = ln[1:-1]
                    try:
                        ln = int(ln)
                    except:
                        pass
                    if not self.files:
                        self.files.append([])
                    self.files[-1].append(ln)
            elif ln == 'var TR_urlArray = {':
                # found the URLs listing (with authtokens)
                mode = 'urls'
            elif mode == 'urls':
                if ln == '};':
                    mode = None
                else:
                    if ln.endswith(','):
                        ln = ln[:-1]
                    name, url = ln.split(': ')
                    url = url.strip()
                    if url.startswith('"'):
                        url = url[1:-1]
                        while 1:
                            i = url.find('\\x')
                            if i < 0:
                                break
                            url = url[:i] + chr(int(url[i+2:i+4], 16)) + url[i+4:]
                    if url.startswith('/'):
                        url = 'http://pages.google.com' + url
                    self.urls[name] = url   
            elif ln.startswith('<option value="/manager/'):
                self.sites.append(ln[24:ln.index('/', 24)])
        for t in self.files:
            t[1] = 'http://%s.googlepages.com/%s' % (self.site, t[1])
        self.lastupdatetime = time.time()

    def _execute(self, cmd, params):
        if not hasattr(self, 'urls'):
            self._update()
        data = self.opener.open(self.urls[cmd], params).read()
        try:
            true, false = True, False
            exec 'result = %s' % data
            return result
        except:
            print '#### (failed) _execute', repr(data)
            return {}

    def isregistered(self):
        '''Returns True if user is registered to the googlepages service.
        '''
        self._update()
        return bool(self.urls)

    def listfiles(self):
        '''Returns a dictionary of uploaded files. Keys are filenames, values
        are tuples of file URLs and file sizes in bytes.
        '''
        self._update()
        t = {}
        for name, url, size in self.files:
            t[name] = (url, size)
        return t
        
    def listsites(self):
        self._update()
        return tuple(self.sites)
        
    def upload(self, name, data):
        '''Uploads a file to googlepages.
        name - file name
        data - file data
        '''
        result = self._execute('upload', {'file': (name, data)})
        try:
            reason = result['type']
            if reason == 'ok':
                try:
                    del self.lastupdatetime
                except AttributeError:
                    pass
                return
        except:
            reason = 'unknown_reason'
        print '#### (failed) upload', name, result, reason
        raise OSError(5, 'Upload failed (%s)' % reason)

    def delete(self, name):
        '''Deletes a file from googlepages.
        name - name of a file to delete
        '''
        # since googlepages returns type=ok even for unexisting files,
        # we check if it exists in the file listing first; we do it
        # by checking the last update and if the file isn't there,
        # we update it and check again
        if name not in [x[0] for x in self.files]:
            self._update()
            if name not in [x[0] for x in self.files]:
                raise OSError(2, 'No such file or directory.')
        result = self._execute('deletePage', name)
        try:
            reason = result['type']
            if reason == 'ok':
                try:
                    del self.lastupdatetime
                except AttributeError:
                    pass
                return
        except:
            reason = 'unknown_reason'
        print '#### (failed) delete', name, result, reason
        raise OSError(5, 'Deletion failed (%s)' % reason)


class GooglePages:
    '''Represents the GooglePages service. Provides a way to login
    into it and manage files stored in different sites.
    '''

    def __init__(self):
        cookies = cookielib.CookieJar()
        self.opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookies),
                                           MultipartPostHandler)

    def login(self, username, password):
        '''Logs into the googlepages service.
        username - google account username,
        password - google account password
        '''
        params = {'Email': username, 'Passwd': password}
        data = self.opener.open('https://www.google.com/accounts/LoginAuth', params).read()
        mode = None
        for ln in data.splitlines():
            ln = ln.strip()
            if ln.startswith('<div class="errormsg"'):
                mode = 'error'
            elif mode == 'error':
                raise ValueError(ln)
        self.username = username
        
        mainsite = self.username.split('@')[0]
        self.mainsite = GooglePagesSite(self.opener, mainsite)
        self.sites = {mainsite: self.mainsite}
        if not self.mainsite.isregistered():
            raise OSError('User not registered to GooglePages?')
        for site in self.mainsite.listsites():
            if site != mainsite:
                self.sites[site] = GooglePagesSite(self.opener, site)
        
    def listsites(self):
        '''Returns a tuple of sites names.
        '''
        s = self.mainsite
        for site in self.sites.values():
            if not hasattr(s, 'lastupdatetime') or \
                (hasattr(site, 'lastupdatetime') and site.lastupdatetime > s.lastupdatetime):
                    s = site
        sitenames = s.listsites()
        sites = {}
        for site in sitenames:
            try:
                sites[site] = self.sites[site]
            except KeyError:
                sites[site] = GooglePagesSite(self.opener, site)
        self.sites = sites
        return sitenames
        
    def __getitem__(self, name):
        '''Returns a site object (see GooglePagesSite class) representing
        a site specified by its name.
        '''
        return self.sites[name]


class GoogleAuthorizer(ftpserver.DummyAuthorizer):
    '''FTP server authorizer. Logs the users into googlepages and
    keeps track of them.
    '''
    
    # username [str] -> GooglePagesFiles()
    users = {}

    def validate_authentication(self, username, password):
        try:
            pages = GooglePages()
            pages.login(username, password)
            self.users[username] = pages
            return True
        except ValueError:
            pass
        except OSError:
            print '#### User %s has probably not registered to GooglePages yet!' % username
        return False
            
    def has_user(self, username):
        #print '#### has_user', username
        return username != 'anonymous'
        
    def has_perm(self, username, perm, path=None):
        return True
        
    def get_perms(self, username):
        return 'lrdw'
        
    def get_home_dir(self, username):
        return os.sep + username
        
    def get_msg_login(self, username):
        return 'Welcome %s' % username

    def get_msg_quit(self, username):
        return 'Goodbye %s' % username


class GooglePagesFile:
    '''Provides a file interface for the FTP server. Redirects files opened
    for reading to HTTP GET using urllib2.urlopen(). Collects data for files
    opened for writing and sends them to googlepages upon closing.
    '''
    
    def __init__(self, username, site, filename, mode):
        #print '#### GooglePagesFile', username, site, filename, mode
        self.username = username
        self.site = site
        self.name = filename
        self.mode = mode
        self.closed = False
        if not username or not site or not filename:
            self.closed = True
            raise IOError(1, 'Operation not permitted')
        if 'r' in mode:
            try:
                self.remote = urllib2.urlopen(GoogleAuthorizer.users[username][site].listfiles()[filename][0])
            except KeyError:
                self.closed = True
                raise IOError(1, 'Operation not permitted')
        else:
            self.data = []
            
    def __del__(self):
        if not self.closed:
            self.close()
            
    def close(self):
        if self.closed:
            return
        self.closed = True
        if 'r' in self.mode:
            self.remote.close()
        else:
            try:
                GoogleAuthorizer.users[self.username][self.site].upload(self.name, ''.join(self.data))
            except OSError, err:
                print >>sys.stderr, '####', err
                raise

    def seek(self, pos, whence=0):
        raise OSError(1, 'Operation not permitted')
        
    def write(self, data):
        if 'r' in self.mode:
            raise OSError(1, 'Operation not permitted')
        self.data.append(data)
        
    def read(self, size=None):
        if 'r' not in self.mode:
            raise OSError(1, 'Operation not permitted')
        return self.remote.read(size)


class GooglePagesFS(ftpserver.AbstractedFS):
    '''File system emulation for FTP server.
    '''
           
    def parse_fspath(self, path):
        '''Returns a (username, site, filename) tuple. For shorter paths
        replaces not provided values with empty strings.
        '''
        if not path.startswith(os.sep):
            raise ValueError('parse_fspath: You have to provide a full path')
        parts = path.split(os.sep)[1:]
        if len(parts) > 3:
            raise ValueError('parse_fspath: Path too deep')
        while len(parts) < 3:
            parts.append('')
        return tuple(parts)
        
    def open(self, filename, mode):
        #print '#### open', filename, mode
        username, site, filename = self.parse_fspath(filename)
        return GooglePagesFile(username, site, filename, mode)
        
    def chdir(self, path):
        #print '#### chdir', path
        if path.startswith(self.root):
            username, site, filename = self.parse_fspath(path)
            if site:
                if site in GoogleAuthorizer.users[username].listsites():
                    if not filename:
                        self.cwd = self.fs2ftp(path)
                        return
            else:
                self.cwd = self.fs2ftp(path)
                return
        raise OSError(2, 'No such file or directory.')

    def mkdir(self, path):
        #print '#### mkdir', path
        raise OSError(1, 'Operation not permitted')
        
    def listdir(self, path):
        #print '#### listdir', path
        username, site, filename = self.parse_fspath(path)
        try:
            if filename:
                raise KeyError
            user = GoogleAuthorizer.users[username]
            if site:
                return user[site].listfiles().keys()
            return list(user.listsites())
        except KeyError:
            raise OSError(2, 'No such file or directory')

    def rmdir(self, path):
        #print '#### rmdir', path
        raise OSError(1, 'Operation not permitted')
        
    def remove(self, path):
        #print '#### remove', path
        username, site, filename = self.parse_fspath(path)
        try:
            GoogleAuthorizer.users[username][site].delete(filename)
        except KeyError:
            raise OSError(2, 'No such file or directory')
        
    def rename(self, src, dst):
        #print '#### rename', src, dst
        raise OSError(1, 'Operation not permitted')
        
    def isfile(self, path):
        return not self.isdir(path)
        
    def islink(self, path):
        return False
        
    def isdir(self, path):
        username, site, filename = self.parse_fspath(path)
        return not filename
        
    def getsize(self, path):
        #print '#### getsize', path
        return self.stat(path).st_size
        
    def getmtime(self, path):
        #print '#### getmtime', path
        return self.stat(path).st_mtime
        
    def realpath(self, path):
        return path
        
    def lexists(self, path):
        #print '#### lexists', path
        username, site, filename = self.parse_fspath(path)
        try:
            if filename:
                return filename in GoogleAuthorizer.users[username][site].listfiles()
            return site in GoogleAuthorizer.users[username].listsites()
        except KeyError:
            raise OSError(2, 'No such file or directory')
            
    def stat(self, path):
        username, site, filename = self.parse_fspath(path)
        try:
            size = GoogleAuthorizer.users[username][site].listfiles()[filename][1]
            return os.stat_result((666, 0L, 0L, 0, 0, 0, size, 0, 0, 0))
        except KeyError:
            raise OSError(2, 'No such file or directory')
        
    exists = lexists
    lstat = stat
        
    def validpath(self, path):
        #print '#### validpath', path
        return True
        
    def get_list_dir(self, path):
        #print '#### get_list_dir', path
        username, site, filename = self.parse_fspath(path)
        # EditPad Pro's FTP client sends "-F -a" options to LIST command! Against RFC!
        def fix(s):
            while (s+' ').startswith('-F ') or (s+' ').startswith('-a '):
                s = s[3:]
            return s
        site = fix(site)
        filename = fix(filename)
        if site:
            try:
                ls = GoogleAuthorizer.users[username][site].listfiles()
                if filename:
                    # check if exists
                    ls[filename]
                    return self.format_list(ls, [filename])
                else:
                    items = ls.keys()
                    items.sort()
                    return self.format_list(ls, items)
            except KeyError:
                raise OSError(2, 'No such file or directory')
        else:
            try:
                user = GoogleAuthorizer.users[username]
                items = list(user.listsites())
                ls = {}
                for site in items:
                    ls[site] = [user[site].url, 0]
                items.sort()
                return self.format_list(ls, items, True)
            except KeyError:
                raise OSError(2, 'No such file or directory')
        
    def format_list(self, ls, items, dirs=False):
        if dirs:
            d = 'd'
        else:
            d = '-'
        for name in items:
            yield '%srw-rw-rw-   1 owner   group   %8s Jan 01 00:00 %s\r\n' % \
                (d, ls[name][1], name)

    def get_stat_dir(self, rawline):
        #print '#### get_stat_dir', rawline
        raise OSError(40, 'unsupported')
        
    def format_mlsx(self, basedir, listing, perms, facts, ignore_err=True):
        #print '#### format_mlsx', basedir, listing, perms, facts, ignore_err
        raise OSError(40, 'unsupported')
        

def main():
    options, args = getopt.getopt(sys.argv[1:], 'hb:a:p:', ['help', 'bind=', 'address=', 'ports='])
    
    if args:
        sys.exit('Unexpected arguments')
    
    # default port    
    bind = 21
    
    ftp_handler = ftpserver.FTPHandler
    ftp_handler.banner = 'googlepagesftpd %s using %s' % (__version__, ftp_handler.banner)
    ftp_handler.authorizer = GoogleAuthorizer()
    ftp_handler.abstracted_fs = GooglePagesFS

    for opt, val in options:
        if opt in ('-h', '--help'):
            print __doc__.strip()
            print 'Version %s (%s)' % (__version__, __date__)
            sys.exit()
        elif opt in ('-b', '--bind'):
            try:
                bind = int(val)
            except ValueError:
                sys.exit('Bind port must be an integer')
        elif opt in ('-a', '--address'):
            from socket import gethostbyname, gaierror
            try:
                ftp_handler.masquerade_address = gethostbyname(val)
            except gaierror, (errno, errmsg):
                sys.exit('Address error: %s' % errmsg)
        elif opt in ('-p', '--ports'):
            try:
                a, b = val.split('-')
                ftp_handler.passive_ports = range(int(a), int(b)+1)
            except ValueError:
                sys.exit('Passive ports must be a <min>-<max> range')

    ftpd = ftpserver.FTPServer(('0.0.0.0', bind), ftp_handler)
    
    ftpd.serve_forever()


if __name__ == '__main__':
    main()
    